diff --git a/services/api-server/src/api_server/html5.py b/services/api-server/src/api_server/html5.py index 036b0a7..bd2dc7a 100644 --- a/services/api-server/src/api_server/html5.py +++ b/services/api-server/src/api_server/html5.py @@ -17,6 +17,7 @@ def _page(title: str, body: str) -> str: + {body} """ diff --git a/services/api-server/src/api_server/html5_ai_structure.py b/services/api-server/src/api_server/html5_ai_structure.py index 947a45d..45de7fa 100644 --- a/services/api-server/src/api_server/html5_ai_structure.py +++ b/services/api-server/src/api_server/html5_ai_structure.py @@ -51,6 +51,7 @@ def render_html5_ai_structure_form(project_id: str, *, saved_credentials: dict[s hx-post="/html5/projects/{quote(project_id)}/ai-structure/run" hx-target="[data-html5-ai-structure-result]" hx-swap="innerHTML" + hx-indicator="[data-ai-structure-progress]" > + """ diff --git a/services/api-server/src/api_server/static/html5/html5-ai-structure.js b/services/api-server/src/api_server/static/html5/html5-ai-structure.js new file mode 100644 index 0000000..77cb8ec --- /dev/null +++ b/services/api-server/src/api_server/static/html5/html5-ai-structure.js @@ -0,0 +1,125 @@ +(function () { + const jobs = new WeakMap(); + + function formatClock(seconds) { + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return String(minutes).padStart(2, "0") + ":" + String(rest).padStart(2, "0"); + } + + function formatHuman(seconds) { + if (seconds < 60) { + return seconds + " сек"; + } + return Math.floor(seconds / 60) + " мин " + String(seconds % 60).padStart(2, "0"); + } + + function stageFor(seconds) { + if (seconds < 10) { + return "Проверка путей и учетных данных"; + } + if (seconds < 45) { + return "Копирование входных файлов"; + } + if (seconds < 120) { + return "Разбор структуры 1С"; + } + if (seconds < 240) { + return "Построение индексов для ИИ"; + } + return "Запись Codex-пакета"; + } + + function etaFor(seconds) { + if (seconds < 10) { + return "1-5 мин"; + } + if (seconds < 45) { + return "до 5 мин"; + } + if (seconds < 120) { + return "2-6 мин"; + } + if (seconds < 300) { + return "еще несколько минут"; + } + return "зависит от размера SMB-папки"; + } + + function update(progress, startedAt) { + const seconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); + const elapsed = progress.querySelector("[data-ai-structure-elapsed]"); + const elapsedLabel = progress.querySelector("[data-ai-structure-elapsed-label]"); + const eta = progress.querySelector("[data-ai-structure-eta]"); + const stage = progress.querySelector("[data-ai-structure-stage]"); + const bar = progress.querySelector("[data-ai-structure-bar]"); + if (elapsed) { + elapsed.textContent = formatClock(seconds); + } + if (elapsedLabel) { + elapsedLabel.textContent = formatHuman(seconds); + } + if (eta) { + eta.textContent = etaFor(seconds); + } + if (stage) { + stage.textContent = stageFor(seconds); + } + if (bar) { + bar.style.width = Math.min(92, 8 + seconds * 0.35) + "%"; + } + } + + function start(form) { + const progress = document.querySelector("[data-ai-structure-progress]"); + if (!progress) { + return; + } + const previous = jobs.get(form); + if (previous) { + window.clearInterval(previous.timer); + } + const startedAt = Date.now(); + progress.hidden = false; + progress.setAttribute("data-ai-structure-progress-state", "running"); + update(progress, startedAt); + const timer = window.setInterval(function () { + update(progress, startedAt); + }, 1000); + jobs.set(form, { progress: progress, timer: timer }); + } + + function stop(form) { + const job = jobs.get(form); + if (!job) { + return; + } + window.clearInterval(job.timer); + job.progress.setAttribute("data-ai-structure-progress-state", "done"); + window.setTimeout(function () { + job.progress.hidden = true; + }, 500); + jobs.delete(form); + } + + document.addEventListener("htmx:beforeRequest", function (event) { + const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null; + if (form) { + start(form); + } + }); + + document.addEventListener("htmx:afterRequest", function (event) { + const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null; + if (form) { + stop(form); + } + }); + + document.addEventListener("htmx:sendError", function (event) { + const form = event.target && event.target.closest ? event.target.closest(".ai-structure-form") : null; + if (form) { + stop(form); + } + }); +})(); diff --git a/services/api-server/src/api_server/static/html5/html5.css b/services/api-server/src/api_server/static/html5/html5.css index 0e42953..74b10fb 100644 --- a/services/api-server/src/api_server/static/html5/html5.css +++ b/services/api-server/src/api_server/static/html5/html5.css @@ -14,9 +14,11 @@ .access-builder{border-bottom:1px solid var(--line);background:#fff}.access-builder-form{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;border-bottom:1px solid var(--line);background:#fbfcfe}.access-builder-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.access-builder-form input,.access-builder-form textarea{min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.access-builder-form input{height:32px}.access-builder-form textarea{min-height:68px;padding:8px;resize:vertical}.access-builder-actions{grid-column:1/-1;display:flex;gap:8px;justify-content:flex-end}.access-builder-result{border-top:1px solid var(--line);background:#fff} .access-card[hx-get]{cursor:pointer}.access-card[hx-get]:hover{background:#f8fbff}.access-user-detail{border-bottom:1px solid var(--line);background:#fff} .ai-structure-form{display:grid;grid-template-columns:1.4fr 1.4fr 180px 120px 180px 180px 120px auto;gap:10px;align-items:end;padding:12px 16px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-structure-form label{display:grid;gap:5px;font-size:11px;font-weight:900;color:var(--muted);text-transform:uppercase}.ai-structure-form input{height:32px;min-width:0;border:1px solid var(--line);background:#fff;padding:0 8px;color:var(--text);font:13px/1.3 system-ui,-apple-system,Segoe UI,sans-serif;text-transform:none}.ai-structure-form .checkbox-row{display:flex;align-items:center;gap:7px;height:32px}.ai-structure-form .checkbox-row input{width:16px;height:16px}.ai-structure-result{background:#fff} +.ai-structure-progress{border-bottom:1px solid var(--line);background:#fff}.ai-progress-head{display:flex;align-items:center;gap:10px;padding:10px 12px;border-bottom:1px solid var(--line);background:#fbfcfe}.ai-progress-head strong{flex:1}.ai-progress-head small{font-weight:900;color:var(--muted);font-variant-numeric:tabular-nums}.ai-progress-spinner{width:18px;height:18px;border:3px solid #c9d6ec;border-top-color:var(--brand);border-radius:50%;animation:aiProgressSpin 900ms linear infinite}.ai-progress-bar{height:6px;background:#eef2f7;overflow:hidden}.ai-progress-bar span{display:block;width:8%;height:100%;background:var(--brand);transition:width 700ms ease}.ai-progress-metrics{display:grid;grid-template-columns:repeat(3,1fr);margin:0}.ai-progress-metrics div{padding:10px 12px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}.ai-progress-metrics div:last-child{border-right:0}.ai-progress-metrics dt{font-size:11px;font-weight:900;text-transform:uppercase;color:var(--muted)}.ai-progress-metrics dd{margin:3px 0 0;font-weight:900}.ai-structure-progress[hidden]{display:none}.ai-structure-progress.htmx-request,.ai-structure-progress[data-ai-structure-progress-state="running"]{display:block}@keyframes aiProgressSpin{to{transform:rotate(360deg)}} @media(max-width:980px){.layout{grid-template-columns:1fr;height:auto}.tree,.inspector{max-height:320px}.editor{min-height:520px}.hero{display:block}.shell{padding:16px}} @media(max-width:980px){.access-layout{grid-template-columns:1fr;height:auto}.access-nav,.access-side{max-height:360px}.access-role-grid{grid-template-columns:1fr}.access-role-grid .access-card:nth-child(odd){border-right:0}} @media(max-width:980px){.access-builder-form{grid-template-columns:1fr}.access-builder-actions{justify-content:stretch}.access-builder-actions button{flex:1}} @media(max-width:980px){.ai-structure-form{grid-template-columns:1fr}} +@media(max-width:980px){.ai-progress-metrics{grid-template-columns:1fr}} @media(max-width:980px){.form-designer-body{grid-template-columns:1fr}.form-property-panel{border-left:0;border-top:1px solid var(--line)}.form-field[data-html5-form-width="half"],.form-field[data-html5-form-width="third"]{grid-column:1/-1}.form-editor-row[data-html5-form-element-editor]{grid-template-columns:1fr}.property-row{grid-template-columns:1fr}} @media(max-width:980px){.setup-layout{grid-template-columns:1fr}.setup-metrics{grid-template-columns:1fr 1fr}.settings-form{grid-template-columns:1fr}} diff --git a/services/api-server/tests/test_api.py b/services/api-server/tests/test_api.py index 6693415..04a121a 100644 --- a/services/api-server/tests/test_api.py +++ b/services/api-server/tests/test_api.py @@ -1735,6 +1735,9 @@ def test_ai_structure_prepare_writes_ai_ready_package(tmp_path: Path): "Пути должны быть доступны серверу", "smb_username", "smb_password", + "data-ai-structure-progress", + "Осталось примерно", + "html5-ai-structure.js", full_page=True, )