Integrate Obfuscator file picker into Contracts
Deploy contracts-flask / validate (push) Canceled after 0s
Deploy contracts-flask / validate (push) Canceled after 0s
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+16
-12
@@ -34,17 +34,9 @@ def transaction():
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Создать/пересоздать БД + схему. Вызывается при старте и после cleanup."""
|
||||
"""Create the database schema without deleting persisted data."""
|
||||
global _db_session_key
|
||||
_db_session_key = time.time()
|
||||
|
||||
# Удалить старый файл + WAL-сателлиты
|
||||
for f in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||
try:
|
||||
os.remove(f)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
_db_session_key = time.time_ns()
|
||||
conn = get_conn()
|
||||
_create_schema(conn)
|
||||
return conn
|
||||
@@ -147,8 +139,20 @@ def _create_schema(conn):
|
||||
|
||||
|
||||
def cleanup_db():
|
||||
"""Удалить БД полностью. Вызывает init_db() для создания новой."""
|
||||
init_db()
|
||||
"""Explicitly delete the database and recreate an empty schema."""
|
||||
global _db_session_key
|
||||
conn = getattr(_local, "conn", None)
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
_local.conn = None
|
||||
_local.session_key = None
|
||||
_db_session_key = time.time_ns()
|
||||
for path in (DB_PATH, DB_PATH + "-wal", DB_PATH + "-shm"):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return init_db()
|
||||
|
||||
|
||||
def get_conn():
|
||||
|
||||
+44
-36
@@ -1,7 +1,7 @@
|
||||
"""API blueprint — groups, documents, supplements, sync, cleanup, spec-current, chat."""
|
||||
from flask import Blueprint, request, jsonify
|
||||
from db import documents, supplements, spec_current
|
||||
from db.connection import execute, query
|
||||
from db.connection import execute, query, transaction
|
||||
from services.grouping import group_documents, apply_groups
|
||||
from config import LLM_URL, LLM_KEY, LLM_MODEL
|
||||
import httpx
|
||||
@@ -38,47 +38,65 @@ def api_document(doc_id):
|
||||
|
||||
@api_bp.route("/api/documents/<doc_id>", methods=["DELETE"])
|
||||
def api_document_delete(doc_id):
|
||||
if not documents.get(doc_id):
|
||||
return jsonify(ok=False, error="not found"), 404
|
||||
with transaction():
|
||||
contract_ids = _delete_document(doc_id)
|
||||
_delete_orphan_contracts(contract_ids)
|
||||
return jsonify(ok=True)
|
||||
|
||||
|
||||
def _delete_document(doc_id):
|
||||
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (doc_id,))
|
||||
for s in (supps or []):
|
||||
contract_ids = {row["contract_id"] for row in supps}
|
||||
for supp in supps:
|
||||
execute(
|
||||
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||
(s["contract_id"], s["id"]),
|
||||
(supp["contract_id"], supp["id"]),
|
||||
)
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (supp["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (supp["id"],))
|
||||
execute("DELETE FROM documents WHERE id = %s", (doc_id,))
|
||||
return jsonify(ok=True)
|
||||
return contract_ids
|
||||
|
||||
|
||||
def _delete_orphan_contracts(contract_ids):
|
||||
for contract_id in contract_ids:
|
||||
remaining = query(
|
||||
"SELECT id FROM supplements WHERE contract_id = %s LIMIT 1",
|
||||
(contract_id,),
|
||||
)
|
||||
if not remaining:
|
||||
execute("DELETE FROM contracts WHERE id = %s", (contract_id,))
|
||||
|
||||
|
||||
# ── Sync ─────────────────────────────────────────────────────────
|
||||
|
||||
@api_bp.route("/api/sync", methods=["POST"])
|
||||
def api_sync():
|
||||
"""Удалить документы, не входящие в keep_ids."""
|
||||
body = request.get_json()
|
||||
keep_ids = set(body.get("keep_ids", []))
|
||||
"""Remove documents absent from keep_ids in the specified batch only."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
batch_id = body.get("batch_id")
|
||||
keep_ids = body.get("keep_ids", [])
|
||||
if not isinstance(batch_id, str) or not batch_id.strip():
|
||||
return jsonify(ok=False, error="batch_id required"), 400
|
||||
if not isinstance(keep_ids, list) or any(not isinstance(doc_id, str) for doc_id in keep_ids):
|
||||
return jsonify(ok=False, error="keep_ids must be a list of document IDs"), 400
|
||||
if len(keep_ids) > 1000:
|
||||
return jsonify(ok=False, error="too many keep_ids (max 1000)"), 400
|
||||
|
||||
docs = query("SELECT id FROM documents", ())
|
||||
docs = query("SELECT id FROM documents WHERE batch_id = %s", (batch_id,))
|
||||
deleted = 0
|
||||
for d in (docs or []):
|
||||
if d["id"] in keep_ids:
|
||||
continue
|
||||
supps = query("SELECT id, contract_id FROM supplements WHERE document_id = %s", (d["id"],))
|
||||
for s in (supps or []):
|
||||
execute(
|
||||
"""DELETE FROM spec_current WHERE contract_id = %s
|
||||
AND last_event_id IN (SELECT id FROM spec_events WHERE supplement_id = %s)""",
|
||||
(s["contract_id"], s["id"]),
|
||||
)
|
||||
execute("DELETE FROM spec_events WHERE supplement_id = %s", (s["id"],))
|
||||
execute("DELETE FROM supplements WHERE id = %s", (s["id"],))
|
||||
execute("DELETE FROM documents WHERE id = %s", (d["id"],))
|
||||
deleted += 1
|
||||
|
||||
execute("DELETE FROM contracts WHERE id NOT IN (SELECT DISTINCT contract_id FROM supplements)")
|
||||
contract_ids = set()
|
||||
keep_ids = set(keep_ids)
|
||||
with transaction():
|
||||
for doc in docs:
|
||||
if doc["id"] in keep_ids:
|
||||
continue
|
||||
contract_ids.update(_delete_document(doc["id"]))
|
||||
deleted += 1
|
||||
_delete_orphan_contracts(contract_ids)
|
||||
return jsonify(ok=True, deleted=deleted)
|
||||
|
||||
|
||||
@@ -170,13 +188,3 @@ def chat():
|
||||
return jsonify(OK=True, ANSWER=answer)
|
||||
except Exception as e:
|
||||
return jsonify(OK=False, ERROR=f"LLM error: {e}")
|
||||
|
||||
|
||||
# ── Cleanup (атомарное удаление БД) ──────────────────────────────
|
||||
|
||||
@api_bp.route("/api/cleanup", methods=["POST"])
|
||||
def api_cleanup():
|
||||
"""Полная очистка: os.remove(DB) + init новой. Данные гарантированно стёрты."""
|
||||
from db.connection import cleanup_db
|
||||
cleanup_db()
|
||||
return jsonify(ok=True, message="all data cleaned")
|
||||
|
||||
@@ -9,6 +9,10 @@ _UPLOAD_FRONTEND = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"upload", "frontend",
|
||||
)
|
||||
_FILE_PICKER_DIST = os.path.join(
|
||||
os.path.dirname(os.path.dirname(_UPLOAD_FRONTEND)),
|
||||
"site", "static", "dist",
|
||||
)
|
||||
|
||||
|
||||
@pages_bp.route("/")
|
||||
@@ -25,3 +29,9 @@ def architect():
|
||||
def upload_frontend(filename):
|
||||
"""Отдать статику ES-модулей upload/frontend (для клиентского ZIP)."""
|
||||
return send_from_directory(_UPLOAD_FRONTEND, filename)
|
||||
|
||||
|
||||
@pages_bp.route("/file-picker/<path:filename>")
|
||||
def file_picker_bundle(filename):
|
||||
"""Отдать собранный picker bundle."""
|
||||
return send_from_directory(_FILE_PICKER_DIST, filename)
|
||||
|
||||
+25
-42
@@ -1,45 +1,51 @@
|
||||
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||
// Contracts App v2.0 — всё на Flask, ВМ больше нет
|
||||
// Contracts App v2.0 — backend на Flask; ВМ используется только как upload buffer.
|
||||
var VM_API = '';
|
||||
// ВМ-буфер загрузки (паттерн drhider): браузер кладёт файл сюда (WebDAV, мимо шлюза),
|
||||
// бэк сам тянет его по /api/upload_refs. Origin должен быть в CORS на nginx ВМ.
|
||||
var VM_UPLOAD_URL = 'https://contracts.kube5s.ru/contracts-upload/';
|
||||
// SITE_URL удалён (Фаза 4) — не использовался
|
||||
|
||||
var fileInput = document.getElementById('fileInput');
|
||||
var fileTable = document.getElementById('fileTable');
|
||||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||
|
||||
// Прогрев upstream + автоочистка при загрузке страницы
|
||||
// Прогрев приложения при загрузке страницы.
|
||||
// ⚠️ Без прогрева первый POST может упасть с ERR_CONNECTION_RESET (MTU/Geneve)
|
||||
(async function init() {
|
||||
try { await fetch('/health'); } catch(e) { /* ignore */ }
|
||||
try { await fetch(VM_API + '/api/cleanup', { method: 'POST' }); } catch(e) { /* ignore */ }
|
||||
})();
|
||||
|
||||
/**
|
||||
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
|
||||
*
|
||||
* ПАТТЕРН: action → мутация state → render(state)
|
||||
*
|
||||
* Фаза 0: вызывает существующий renderTable().
|
||||
* renderTable читает state.files напрямую (state — глобальный объект).
|
||||
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
|
||||
*
|
||||
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
|
||||
* Никакой прямой манипуляции DOM в обход render().
|
||||
* console.log(state) показывает ВСЁ состояние в любой момент.
|
||||
* Обновить отображение picker и текущий шаг pipeline после изменения state.
|
||||
*/
|
||||
function render(state) {
|
||||
renderFiles(state);
|
||||
renderStepper(state);
|
||||
}
|
||||
|
||||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||||
// → вынесены в files.js (Фаза 1)
|
||||
if (!window.FilePicker) throw new Error('Не загружен бандл FilePicker');
|
||||
filePicker = window.FilePicker.initFilePicker({
|
||||
mount: '#filePicker',
|
||||
allowedExt: ['.pdf', '.doc', '.docx'],
|
||||
limits: FILE_PICKER_LIMITS,
|
||||
onChange: onPickerFilesChange,
|
||||
onError: function(error, file) {
|
||||
filePicker.setMessage('Ошибка чтения ' + file.name + ': ' + error.message);
|
||||
},
|
||||
onDetails: function(id) {
|
||||
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
|
||||
if (index >= 0) toggleClassifyDetail(index);
|
||||
},
|
||||
onText: function(id) {
|
||||
var index = state.files.findIndex(function(file) { return file.pickerId === id; });
|
||||
if (index >= 0) showText(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Picker selection, upload queue, syncDB, document details and finalization
|
||||
// are wired by files.js.
|
||||
|
||||
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
|
||||
|
||||
@@ -97,36 +103,13 @@ function resetStepper(fromId) {
|
||||
}
|
||||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||||
// Picker rows are rendered by the FilePicker integration in files.js.
|
||||
|
||||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||||
// toggleClassifyDetail → files.js (Фаза 1)
|
||||
|
||||
// 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() {
|
||||
var newFiles = Array.from(fileInput.files);
|
||||
onFilesSelected(newFiles);
|
||||
});
|
||||
|
||||
// Выбор папки (webkitdirectory) — та же обработка, что и файлы
|
||||
var folderInput = document.getElementById('folderInput');
|
||||
var folderBtn = document.getElementById('folderBtn');
|
||||
if (folderBtn && folderInput) {
|
||||
folderBtn.addEventListener('click', function() { folderInput.click(); });
|
||||
folderInput.addEventListener('change', function() {
|
||||
var files = Array.from(folderInput.files).filter(function(f) {
|
||||
var n = f.name.toLowerCase();
|
||||
return n.endsWith('.pdf') || n.endsWith('.doc') || n.endsWith('.docx') || n.endsWith('.zip');
|
||||
});
|
||||
if (files.length) onFilesSelected(files);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Классификация ──────────────────────────────────────────
|
||||
function showClassifyBtn() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
/**
|
||||
* Форматирует размер файла в человекочитаемый вид.
|
||||
* Используется в renderTable() для колонки "Размер".
|
||||
* Используется в модальном окне документа.
|
||||
*/
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '—';
|
||||
@@ -17,7 +17,7 @@ function formatSize(bytes) {
|
||||
|
||||
/**
|
||||
* Форматирует timestamp в российскую дату + время.
|
||||
* Используется в renderTable() для колонки "Изменён".
|
||||
* Используется в модальном окне документа.
|
||||
*/
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '—';
|
||||
@@ -33,15 +33,8 @@ function formatDate(ts) {
|
||||
* Если очередь опустела — сбрасывает state.contractId.
|
||||
*/
|
||||
function removeFile(i) {
|
||||
state.files.splice(i, 1);
|
||||
if (state.files.length === 0) state.contractId = null;
|
||||
render(state);
|
||||
// Синхронизировать БД с таблицей
|
||||
if (typeof syncDB === 'function') syncDB();
|
||||
// Сбросить прогресс при изменении состава файлов
|
||||
if (typeof resetStepper === 'function') resetStepper('stepUpload');
|
||||
// Разблокировать кнопку классификации при изменении состава файлов
|
||||
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
||||
var file = state.files[i];
|
||||
if (file && filePicker) filePicker.remove(file.pickerId);
|
||||
}
|
||||
window.removeFile = removeFile;
|
||||
|
||||
|
||||
Vendored
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Arjun Barrett
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Vendored
+1513
File diff suppressed because it is too large
Load Diff
+256
-267
@@ -3,134 +3,48 @@
|
||||
*
|
||||
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||||
*
|
||||
* 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 — чистая функция рендеринга.
|
||||
* FilePicker owns selection/tree rendering; this module connects it to the
|
||||
* Contracts batch, upload sink, and document pipeline.
|
||||
*
|
||||
* Состав и обработка документов синхронизируются с FilePicker в app.js.
|
||||
*/
|
||||
function statusToHTML(st) {
|
||||
if (!st || !st.kind) return '';
|
||||
switch (st.kind) {
|
||||
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||||
case 'converting': 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 '';
|
||||
}
|
||||
}
|
||||
|
||||
var filePicker = null;
|
||||
var uploadQueue = Promise.resolve();
|
||||
var uploadTasks = new Map();
|
||||
var queuedUploads = new Set();
|
||||
var pendingUploads = 0;
|
||||
var FILE_PICKER_LIMITS = {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 500 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
};
|
||||
|
||||
/**
|
||||
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
|
||||
*
|
||||
* Читает state.files, генерирует HTML для #fileTable.
|
||||
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
|
||||
* Вызывает lucide.createIcons() для иконок.
|
||||
*
|
||||
* Вход: state (весь объект состояния, но читает только state.files).
|
||||
* Выход: мутирует DOM (fileTable.innerHTML).
|
||||
* renderFiles(state) — обновить отображение дерева и статусов picker.
|
||||
*/
|
||||
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();
|
||||
if (filePicker) filePicker.render();
|
||||
}
|
||||
|
||||
/**
|
||||
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
||||
*
|
||||
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
|
||||
* Всё, чего нет в state.files, будет удалено из БД (cascade).
|
||||
* Вызывается после каждого изменения состава файлов (removeFile, upload).
|
||||
* Синхронизирует keep_ids только для текущего batch_id.
|
||||
*/
|
||||
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) { /* сервер недоступен — не критично */ }
|
||||
var keepIds = Array.from(new Set(state.files.map(function(f) { return f.doc_id; }).filter(Boolean)));
|
||||
var response = await fetch(VM_API + '/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({batch_id: state.batchId, keep_ids: keepIds})
|
||||
});
|
||||
var data = await response.json();
|
||||
if (!response.ok || !data.ok) {
|
||||
throw new Error(data.error || 'Не удалось синхронизировать документы');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +60,47 @@ async function syncDB() {
|
||||
window.toggleClassifyDetail = async function(i) {
|
||||
var detailRow = document.getElementById('detail_' + i);
|
||||
var expandIcon = document.getElementById('expand_' + i);
|
||||
if (!detailRow) return;
|
||||
if (!detailRow) {
|
||||
var file = state.files[i];
|
||||
if (!file || !file.doc_id) return;
|
||||
document.getElementById('modalTitle').textContent = 'Классификация — ' + file.name;
|
||||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||
document.getElementById('modalOverlay').classList.add('open');
|
||||
try {
|
||||
var detailResponse = await fetch(VM_API + '/api/documents/' + encodeURIComponent(file.doc_id));
|
||||
var detailData = await detailResponse.json();
|
||||
if (!detailResponse.ok || !detailData.ok) {
|
||||
throw new Error(detailData.error || 'Ошибка загрузки документа');
|
||||
}
|
||||
var detailHtml = '<div style="font-weight:600;margin-bottom:8px;">Результат классификации</div>';
|
||||
[['Тип', detailData.doc_type], ['Номер', detailData.own_number],
|
||||
['Родительский', detailData.parent_number], ['Дата', detailData.doc_date],
|
||||
['Контрагент', detailData.counterparty]].forEach(function(pair) {
|
||||
if (pair[1]) detailHtml += '<div class="kv"><span class="k">' + pair[0]
|
||||
+ '</span><span class="v">' + escHtml(pair[1]) + '</span></div>';
|
||||
});
|
||||
if (detailData.classify_input) {
|
||||
detailHtml += '<details><summary>Текст, отправленный в LLM</summary><pre>'
|
||||
+ escHtml(detailData.classify_input) + '</pre></details>';
|
||||
}
|
||||
if (detailData.classify_raw) {
|
||||
detailHtml += '<details><summary>Сырой ответ LLM</summary><pre>'
|
||||
+ escHtml(detailData.classify_raw) + '</pre></details>';
|
||||
}
|
||||
if (detailData.elements_json) {
|
||||
var parsedElements = typeof detailData.elements_json === 'string'
|
||||
? detailData.elements_json : JSON.stringify(detailData.elements_json, null, 2);
|
||||
detailHtml += '<details><summary>Распарсенные элементы</summary><pre>'
|
||||
+ escHtml(parsedElements.substring(0, 5000))
|
||||
+ (parsedElements.length > 5000 ? '\n... (обрезано)' : '')
|
||||
+ '</pre></details>';
|
||||
}
|
||||
document.getElementById('modalBody').innerHTML = detailHtml;
|
||||
} catch (error) {
|
||||
document.getElementById('modalBody').textContent = 'Ошибка: ' + error.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (detailRow.style.display !== 'none') {
|
||||
detailRow.style.display = 'none';
|
||||
@@ -204,65 +158,6 @@ window.toggleClassifyDetail = async function(i) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔ uploadFile — загрузка через ВМ-буфер (паттерн drhider).
|
||||
*
|
||||
* Фаза 1: PUT файла на ВМ (WebDAV /contracts-upload/, мимо шлюза кластера ~64КБ).
|
||||
* Фаза 2: POST /api/upload_refs {files:[{name,size,url}]} — бэк сам тянет файл с ВМ.
|
||||
* Честный счётчик времени: ⏳ соединение... Nс → ⏳ отправка... Nс (fetch не даёт progress).
|
||||
*/
|
||||
function uploadFile(file, onProgress, zipSource) {
|
||||
var startTime = Date.now();
|
||||
var phase = 'connecting'; // connecting → uploading
|
||||
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||||
var token = crypto.randomUUID();
|
||||
var vmUrl = VM_UPLOAD_URL + token + '_0';
|
||||
|
||||
// Честный счётчик: каждую секунду обновляем elapsed
|
||||
var timer = setInterval(function() {
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||||
}, 1000);
|
||||
|
||||
// Фаза 1: PUT файла на ВМ-буфер
|
||||
return fetch(vmUrl, { method: 'PUT', body: file, headers: { 'Content-Type': 'application/octet-stream' } })
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('VM upload HTTP ' + r.status);
|
||||
phase = 'uploading';
|
||||
// Фаза 2: refs на бэкенд → pull с ВМ
|
||||
var body = { batch_id: state.batchId, files: [{ name: file.name, size: file.size, url: vmUrl }] };
|
||||
if (state.contractId) body.contract_id = state.contractId;
|
||||
if (zipSource) body.zip_source = zipSource;
|
||||
return fetch('/api/upload_refs?_=' + Date.now(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
})
|
||||
.then(function(r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
clearInterval(timer);
|
||||
if (data.ok && data.results && data.results.length === 1) {
|
||||
var res = data.results[0];
|
||||
if (!res.ok) throw new Error(res.error || 'Неизвестная ошибка');
|
||||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||||
return res; // {ok, doc_id, contract_id, parsed}
|
||||
}
|
||||
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.
|
||||
*
|
||||
@@ -291,25 +186,6 @@ async function refreshSupps() {
|
||||
} 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).
|
||||
*
|
||||
@@ -338,7 +214,7 @@ function applyParseResult(entry, parsed, elapsed) {
|
||||
/**
|
||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||
*
|
||||
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
|
||||
* Вызывается после завершения очереди выбранных файлов.
|
||||
* 1. Обновляет supplement_id/type через refreshSupps()
|
||||
* 2. Отмечает шаг «Загрузка» как готовый
|
||||
* 3. Активирует шаг «Классификация»
|
||||
@@ -346,14 +222,18 @@ function applyParseResult(entry, parsed, elapsed) {
|
||||
* 5. Показывает кнопки (LLM, классификация)
|
||||
*/
|
||||
async function finalizeUpload() {
|
||||
if (!state.files.some(function(file) { return file.doc_id; })) {
|
||||
var llmButton = document.getElementById('llmBtn');
|
||||
var classifyButton = document.getElementById('classifyBtn');
|
||||
if (llmButton) llmButton.style.display = 'none';
|
||||
if (classifyButton) classifyButton.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
await refreshSupps();
|
||||
await syncDB();
|
||||
stepDone('stepUpload');
|
||||
stepActive('stepClassify');
|
||||
syncDB();
|
||||
|
||||
fileInput.value = '';
|
||||
fileInput.disabled = false;
|
||||
|
||||
|
||||
var llmBtn = document.getElementById('llmBtn');
|
||||
llmBtn.style.display = 'flex';
|
||||
llmBtn.disabled = false;
|
||||
@@ -361,79 +241,188 @@ async function finalizeUpload() {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* expandZipClient(f) — клиентское рекурсивное раскрытие ZIP (drhider listZipFiles).
|
||||
* Каждый документ из архива (включая вложенные zip) добавляется в state.files
|
||||
* с zip_source = имя архива. Fallback: если раскрыть не удалось — архив как есть.
|
||||
*/
|
||||
async function expandZipClient(f) {
|
||||
var nested = [];
|
||||
function zipSourceFromPath(path) {
|
||||
var parts = String(path || '').split('/');
|
||||
var lastZip = -1;
|
||||
parts.forEach(function(part, index) {
|
||||
if (part.toLowerCase().endsWith('.zip')) lastZip = index;
|
||||
});
|
||||
return lastZip < 0 ? null : parts.slice(0, lastZip + 1).join('/');
|
||||
}
|
||||
|
||||
function isPickerEntrySelected(entry) {
|
||||
return state.files.some(function(file) { return file.pickerId === entry.pickerId; });
|
||||
}
|
||||
|
||||
function setPickerFileStatus(entry, kind, text) {
|
||||
entry.status = { kind: kind, text: text };
|
||||
if (filePicker) filePicker.setStatus(entry.pickerId, entry.status);
|
||||
}
|
||||
|
||||
function showPickerError(error) {
|
||||
if (filePicker) filePicker.setMessage(error.message || String(error));
|
||||
}
|
||||
|
||||
async function uploadPickerEntry(entry) {
|
||||
if (!isPickerEntrySelected(entry) || entry.uploadBlocked) return;
|
||||
|
||||
var task = { controller: new AbortController(), phase: 'put' };
|
||||
uploadTasks.set(entry.pickerId, task);
|
||||
setPickerFileStatus(entry, 'uploading', '⏳ Подготовка к загрузке');
|
||||
var sinkResult;
|
||||
try {
|
||||
if (window.listZipFiles) {
|
||||
nested = await window.listZipFiles(f, ['.pdf', '.doc', '.docx']);
|
||||
var response = await window.FilePicker.uploadViaVM([{
|
||||
name: entry.file.name,
|
||||
size: entry.file.size,
|
||||
file: entry.file,
|
||||
path: entry.pickerPath
|
||||
}], {
|
||||
vmUploadUrl: VM_UPLOAD_URL,
|
||||
backendUploadUrl: VM_API + '/api/upload_refs',
|
||||
signal: task.controller.signal,
|
||||
getExtraPayload: function() {
|
||||
var payload = { batch_id: state.batchId };
|
||||
if (state.contractId) payload.contract_id = state.contractId;
|
||||
if (entry.zip_source) payload.zip_source = entry.zip_source;
|
||||
return payload;
|
||||
},
|
||||
onFileStatus: function(index, message) {
|
||||
if (message.indexOf('⚡') === 0) task.phase = 'backend';
|
||||
var kind = message.indexOf('✗') === 0 ? 'error'
|
||||
: message.indexOf('✓') === 0 ? 'parsing'
|
||||
: task.phase === 'backend' ? 'parsing' : 'uploading';
|
||||
if (isPickerEntrySelected(entry)) setPickerFileStatus(entry, kind, message);
|
||||
},
|
||||
onFileComplete: function(fileResult) {
|
||||
sinkResult = fileResult.result;
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(response.error || 'Ошибка загрузки файла');
|
||||
if (!sinkResult) throw new Error('Сервис не вернул результат обработки файла');
|
||||
if (sinkResult.ok && !sinkResult.doc_id) {
|
||||
throw new Error('Сервис обработал файл без идентификатора документа');
|
||||
}
|
||||
} catch (e) {
|
||||
nested = [];
|
||||
}
|
||||
if (!nested || nested.length === 0) {
|
||||
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;
|
||||
return;
|
||||
}
|
||||
for (var z = 0; z < nested.length; z++) {
|
||||
state.files.push({ name: nested[z].name, lastModified: nested[z].lastModified, size: nested[z].size, file: nested[z], status: { kind: 'connecting', elapsed: 0 }, zip_source: f.name });
|
||||
state.files[state.files.length - 1]._pendingFile = nested[z];
|
||||
|
||||
if (sinkResult.error === 'duplicate' && sinkResult.doc_id) {
|
||||
entry.doc_id = sinkResult.doc_id;
|
||||
entry.uploaded = true;
|
||||
entry.status = { kind: 'duplicate', text: '⤿ дубликат' };
|
||||
} else if (sinkResult.ok) {
|
||||
entry.doc_id = sinkResult.doc_id;
|
||||
entry.uploaded = true;
|
||||
if (sinkResult.contract_id) state.contractId = sinkResult.contract_id;
|
||||
applyParseResult(entry, sinkResult.parsed);
|
||||
} else {
|
||||
throw new Error(sinkResult.error || 'Не удалось обработать файл');
|
||||
}
|
||||
|
||||
if (isPickerEntrySelected(entry)) {
|
||||
filePicker.setDocument(entry.pickerId, entry.doc_id);
|
||||
setPickerFileStatus(entry, entry.status.kind, entry.status.text
|
||||
|| (entry.status.kind === 'parsed'
|
||||
? '✓ ' + (entry.status.count || 0) + ' эл.'
|
||||
: entry.status.text || 'Ошибка обработки'));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(task.controller.signal.aborted && !isPickerEntrySelected(entry))) {
|
||||
setPickerFileStatus(entry, 'error', '✗ ' + error.message);
|
||||
}
|
||||
} finally {
|
||||
uploadTasks.delete(entry.pickerId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ⛔ НЕ МЕНЯТЬ ⛔ 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 expandZipClient(f);
|
||||
continue;
|
||||
function enqueuePickerUpload(entry) {
|
||||
if (queuedUploads.has(entry.pickerId) || uploadTasks.has(entry.pickerId)) return;
|
||||
queuedUploads.add(entry.pickerId);
|
||||
pendingUploads += 1;
|
||||
var run = function() { return uploadPickerEntry(entry); };
|
||||
uploadQueue = uploadQueue.then(run, run).finally(async function() {
|
||||
queuedUploads.delete(entry.pickerId);
|
||||
pendingUploads -= 1;
|
||||
if (pendingUploads === 0) {
|
||||
try {
|
||||
await finalizeUpload();
|
||||
} catch (error) {
|
||||
showPickerError(error);
|
||||
}
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleBatchSync() {
|
||||
var sync = async function() {
|
||||
await syncDB();
|
||||
if (state.files.some(function(file) { return file.doc_id; })) {
|
||||
showClassifyBtn();
|
||||
var compareButton = document.getElementById('llmBtn');
|
||||
if (compareButton) compareButton.style.display = 'flex';
|
||||
}
|
||||
};
|
||||
uploadQueue = uploadQueue.then(sync, sync).catch(showPickerError);
|
||||
}
|
||||
|
||||
function onPickerFilesChange(pickerFiles) {
|
||||
var previous = new Map(state.files.map(function(file) { return [file.pickerId, file]; }));
|
||||
var incomingIds = new Set(pickerFiles.map(function(file) { return file.id; }));
|
||||
var added = [];
|
||||
var nextFiles = [];
|
||||
var toQueue = [];
|
||||
var selectedBytes = 0;
|
||||
pickerFiles.forEach(function(pickerFile) {
|
||||
selectedBytes += pickerFile.size;
|
||||
var entry = previous.get(pickerFile.id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
pickerId: pickerFile.id,
|
||||
pickerPath: pickerFile.path,
|
||||
name: pickerFile.name,
|
||||
size: pickerFile.size,
|
||||
lastModified: pickerFile.file.lastModified,
|
||||
file: pickerFile.file,
|
||||
zip_source: zipSourceFromPath(pickerFile.path),
|
||||
status: { kind: 'connecting' }
|
||||
};
|
||||
added.push(entry);
|
||||
}
|
||||
if (!entry.doc_id && !uploadTasks.has(entry.pickerId)) {
|
||||
var limitError = entry.size > FILE_PICKER_LIMITS.maxEntryBytes
|
||||
? 'Файл превышает лимит 50 МБ'
|
||||
: selectedBytes > FILE_PICKER_LIMITS.maxTotalBytes ? 'Превышен общий лимит 500 МБ' : '';
|
||||
if (limitError) {
|
||||
entry.uploadBlocked = true;
|
||||
entry.status = { kind: 'error', text: limitError };
|
||||
filePicker.setStatus(entry.pickerId, entry.status);
|
||||
} else if (entry.uploadBlocked) {
|
||||
delete entry.uploadBlocked;
|
||||
entry.status = { kind: 'connecting' };
|
||||
if (!queuedUploads.has(entry.pickerId)) toQueue.push(entry);
|
||||
} else if (added.indexOf(entry) !== -1) {
|
||||
toQueue.push(entry);
|
||||
}
|
||||
}
|
||||
nextFiles.push(entry);
|
||||
});
|
||||
|
||||
var removed = state.files.filter(function(file) { return !incomingIds.has(file.pickerId); });
|
||||
removed.forEach(function(file) {
|
||||
var task = uploadTasks.get(file.pickerId);
|
||||
if (task && task.phase === 'put') task.controller.abort();
|
||||
});
|
||||
|
||||
var changed = added.length > 0 || removed.length > 0 || toQueue.length > 0;
|
||||
state.files = nextFiles;
|
||||
if (!state.files.some(function(file) { return file.doc_id; })) state.contractId = null;
|
||||
if (changed) {
|
||||
resetStepper('stepUpload');
|
||||
var classifyButton = document.getElementById('classifyBtn');
|
||||
if (classifyButton) classifyButton.style.display = 'none';
|
||||
var compareButton = document.getElementById('llmBtn');
|
||||
if (compareButton) compareButton.style.display = 'none';
|
||||
}
|
||||
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);
|
||||
}, entry.zip_source);
|
||||
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();
|
||||
toQueue.forEach(enqueuePickerUpload);
|
||||
if (removed.length) scheduleBatchSync();
|
||||
}
|
||||
|
||||
+23
-19
@@ -68,6 +68,26 @@
|
||||
.text-pane th { font-size: 10px; padding: 4px 6px; }
|
||||
.text-pane td { padding: 3px 6px; font-size: 11px; }
|
||||
.text-pane .pane-title { font-weight: 600; font-size: 12px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; }
|
||||
#filePicker .file-picker { color: var(--foreground); font-family: system-ui, sans-serif; }
|
||||
#filePicker .fp-toolbar { margin: 0 0 8px; }
|
||||
#filePicker .fp-toolbar button { min-height: 36px; border: 1px solid var(--brand-gray); border-radius: 6px; padding: 0 14px; color: var(--foreground); background: var(--background); font: inherit; }
|
||||
#filePicker .fp-toolbar button:first-of-type { color: #fff; background: var(--brand-primary); border-color: var(--brand-primary); }
|
||||
#filePicker .fp-toolbar button.secondary { color: var(--foreground); background: var(--background); }
|
||||
#filePicker .fp-toolbar button.quiet { color: var(--muted); }
|
||||
#filePicker .fp-table-wrap { max-height: 50vh; overflow: auto; border: 1px solid var(--brand-gray); border-radius: 6px; }
|
||||
#filePicker .fp-table { font: 13px system-ui, sans-serif; }
|
||||
#filePicker .fp-table th, #filePicker .fp-table td { padding: 6px 10px; border-color: var(--brand-gray); }
|
||||
#filePicker .fp-table th { color: var(--muted); letter-spacing: 0; }
|
||||
#filePicker .tree-name { min-width: 0; padding-top: 0; padding-bottom: 0; color: var(--foreground); font-family: inherit; }
|
||||
#filePicker .tree-file .tree-name { overflow: hidden; text-overflow: ellipsis; }
|
||||
#filePicker .fp-actions { white-space: nowrap; }
|
||||
#filePicker .fp-action { margin-right: 4px; padding: 3px 6px; border: 0; border-radius: 4px; color: var(--brand-primary); background: transparent; cursor: pointer; font: 11px system-ui, sans-serif; }
|
||||
#filePicker .fp-status-uploading { color: var(--brand-primary); }
|
||||
#filePicker .fp-status-parsing { color: var(--brand-primary); }
|
||||
#filePicker .fp-status-parsed { color: var(--green); }
|
||||
#filePicker .fp-status-duplicate { color: var(--muted); }
|
||||
#filePicker .fp-status-error { color: var(--destructive); }
|
||||
#filePicker .fp-status, #filePicker .fp-count { color: var(--muted); font: 12px system-ui, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -90,22 +110,9 @@
|
||||
Загрузка договоров/приложений/спецификаций
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<input type="file" id="fileInput" accept=".doc,.docx,.pdf,.zip" multiple style="margin-bottom:6px;width:100%;">
|
||||
<input type="file" id="folderInput" webkitdirectory multiple style="display:none;">
|
||||
<div style="margin-bottom:6px;">
|
||||
<button type="button" class="btn" id="folderBtn" style="font-size:12px;height:30px;">📁 Выбрать папку</button>
|
||||
</div>
|
||||
<div style="text-align:right;font-size:11px;color:var(--muted);margin-bottom:6px;">Порядок определяется автоматически при классификации</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">⚠ При совпадении имён — запрос на перезапись (OK / Отмена). Файлы из ZIP-архивов загружаются через тот же поток.</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Парсинг</th><th style="width:30px;">Текст</th><th style="width:30px;">✕</th></tr>
|
||||
</thead>
|
||||
<tbody id="fileTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px;">Выберите PDF, DOC или DOCX. ZIP раскрываются рекурсивно; правила из корневого <code>obscure.lst</code> применяются при выборе папки.</div>
|
||||
<div id="filePicker"></div>
|
||||
|
||||
<button class="btn btn-primary" id="llmBtn" style="width:100%;justify-content:center;margin-top:8px;display:none;background:var(--green);border-color:var(--green);">
|
||||
<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение
|
||||
@@ -220,10 +227,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { listZipFiles } from '/upload/zip/list_zip_files.js';
|
||||
window.listZipFiles = listZipFiles;
|
||||
</script>
|
||||
<script src="/file-picker/file-picker.iife.js"></script>
|
||||
<script src="/static/state.js?v=2.0.21"></script>
|
||||
<script src="/static/app_utils.js?v=2.0.21"></script>
|
||||
<script src="/static/files.js?v=2.0.21"></script>
|
||||
|
||||
Reference in New Issue
Block a user